iT邦幫忙

2024 iThome 鐵人賽

DAY 16
2
Modern Web

為你自己寫 Vue Component系列 第 16

[為你自己寫 Vue Component] AtomicTable

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicTable

表格(Table)是一種專門用來展示結構化資料的元件,它可以幫助使用者以易於掃描的方式瀏覽資料,使他們能夠快速辨別資料中的模式並進行分析。

AtomicTable

除了展示資料,表格元件還可以包含以下功能:

  • 對應視覺化圖表:表格可以搭配相應的圖表,幫助使用者更直觀地理解數據。
  • 數據查詢和操作工具:表格可以搭配查詢工具和操作工具,允許用戶對資料進行篩選、排序、編輯等操作,從而提高資料管理的效率。

元件分析

元件架構

AtomicTable 元件架構

  1. Head:表格的 Header,通常包含每個欄位的標題。
  2. Body:表格的 Body。
  3. Row:表格每一列的資料。
  4. Column:表格每一列的欄位。
  5. Cell:表個 Row 與 Column 的交集。

功能設計

在開始實作前,我們先研究各個 UI Library 的 Table 元件是如何設計的。

Element Plus

Element Plus Table

<template>
  <ElTable :data="tableData" style="width: 100%">
    <ElTableColumn prop="date" label="Date" width="180" sortable />
    <ElTableColumn prop="name" label="Name" width="180" />
    <ElTableColumn prop="address" label="Address" />
  </ElTable>
</template>

Element Plus 的 <ElTable> 透過 data 傳入表格的資料,它必須是一個陣列。而表格的每個欄位(Columns)則使用 <ElTableColumn> 定義。

<ElTableColumn> 可以說是整個表格設定的核心,功能非常豐富,我們無法全部列舉。常用的設定像是 prop 可以用來設定對應到 data 中每個物件的指定屬性,而 label 則定義該欄位的標題。其他比較重要的設定包括 align,用於設定欄位的對齊方式;sortable,可以定義欄位是否可以排序;formatter,可以定義欄位的格式化方式。

Vuetify

在 Vuetify 中,如果只需要最簡單的表格功能,可以選用 <VTable>;比較複雜的功能可依照使用情境選用 <VDataTable><VDataTableServer><VDataTableVirtual>

Vuetify Data Table

<template>
<VDataTable 
  :headers="headers"
  :items="plants"
  item-key="name"
/>
</template>

Vuetify 的 <VDataTable> 可以使用 headers 來定義表格的欄位,而 items 是表格的資料。item-key 則是用來指定資料中的唯一值。

headers 的功能與 Element Plus 的 <ElTableColumn> 類似,如果將 Element Plus 範例中的 <ElTableColumn> 轉換成 headers 的設定,會變成以下的樣子:

const headers = [
  { text: 'Date', key: 'date', width: '180', sortable: true },
  { text: 'Name', key: 'name', width: '180' },
  { text: 'Address', key: 'address' }
]

另外,itemKey 的設計讓開發人員可以依照專案需求調整資料為唯一值的欄位,像是 iduuidulid 都是很常見的唯一值。

Nuxt UI

Nuxt UI Table

<template>
  <UTable :columns="columns" :rows="people" />
</template>

Nuxt UI 的 <UTable> 接受 columns 設定每個欄位的標題與屬性,而 rows 則是表格的資料。

Nuxt UI 的 columns 相較於 Element Plus 與 Vuetify 更為簡單,也都是我們最常見的設定。

  • label - 定義欄位的標題。
  • key - 定義對應到 rows 中的屬性。
  • sortable - 定義欄位是否可以排序。
  • direction - 第一次點選時的排序方向。
  • class - 每個單元格(Cell)的 class。
  • rowClass - 整個欄位(Column)的 class。
  • sort - 排序 function 的設定。

綜合以上並結合自身經驗,我們統整出 <AtomicTable> 的功能:

  • 透過 columns 定義表格的欄位。
  • 透過 items 定義表格的資料。
  • 可以透過 itemKey 定義資料中的唯一值。
  • 可以透過 sort 定義排序的方式。
  • 可以透過 bodyRowClass 定義每個 Row 的 class。
  • 可以透過 bodyCellClass 定義每個單元格的 class。

columns 可以定義的屬性如下:

  • key:定義對應到 items 中的屬性。
  • label:定義欄位的標題。
  • width:欄位寬度。
  • align:欄位對齊方式。
  • class:每個欄位的 class。
  • render:定義欄位的格式化方式。

使用結構如下:

<template>
  <AtomicTable
    :columns="columns"
    :items="items"
    itemKey="id"
    :sort="sort"
    bodyRowClass="table-row"
    bodyCellClass="table-cell"
  />
</template>

元件實作

首先,我們將需求中提到的功能整理成 propsemit 的介面,我們會需要下列屬性:

Props

名稱 型別 預設值 說明
columns Array<{ key: string, label: string, ... }> [] 定義表格的欄位
items Array<{ [key: string]: any }> [] 定義表格的資料
itemKey string 'id' 定義資料的唯一值
sort { column: string, direction: 'asc' | 'desc' } undefined 定義排序方式
bodyRowClass any '' 定義每個 Row 的 class
bodyCellClass any '' 定義每個 Cell 的 class

Emits

名稱 參數 說明
update:sort { column: string, direction: 'asc' | 'desc' } 更新排序方式
type TableItem = Record<string, any>;

interface TableColumn {
  key: string;
  sortable?: boolean;
  label?: string;
  width?: number | string;
  align?: 'left' | 'center' | 'right';
  class?: any;
  headCellClass?: any;
  bodyCellClass?: any;
  render?(value: any, index: number, item: TableItem): any;
}

interface AtomicTableProps {
  columns: TableColumn[];
  items?: TableItem[];
  itemKey?: string;

  sort?: {
    column?: string | undefined;
    direction?: 'asc' | 'desc' | undefined;
  };

  headRowClass?: any;
  bodyRowClass?: any;
  bodyCellClass?: any;
}

interface AtomicTableEmits {
  (event: 'update:sort', value: AtomicTableProps['sort']): void;
}

const props = withDefaults(defineProps<AtomicTableProps>(), {
  columns: () => [],
  items: () => [],
  itemKey: 'id',
  sort: undefined,
  headRowClass: undefined,
  bodyRowClass: undefined,
  bodyCellClass: undefined,
});

const emit = defineEmits<AtomicTableEmits>();

我們先做出一個基本的表格,並且讓表格撐滿畫面。

<template>
  <div class="atomic-table">
    <table class="atomic-table__table">
      <thead class="atomic-table__thead">
        <tr class="atomic-table__row">
          <th
            v-for="column in columns"
            :key="column.key"
            class="atomic-table__cell"
          >
            {{ column.label }}
          </th>
        </tr>
      </thead>
      <tbody class="atomic-table__body">
        <tr
          v-for="item in items"
          :key="item[itemKey]"
          class="atomic-table__row"
        >
          <td
            v-for="column in columns"
            :key="column.key"
             class="atomic-table__cell"
          >
            {{ item[column.key] }}
          </td>
        </tr>
      </tbody>
    </table>
  <div>
</template>
.atomic-table {
  &__table {
    min-width: 100%;
  }
}

簡單的 AtomicTable

接下來,我們處理 columns 中的各種設定,像是 widthalignclass

在表格中,如果要定義每個欄位的 width,我們可以利用 <col> 來定義。

const isNumberish = (value: unknown): value is number | `${number}` => {
  return isNumber(value) || (isString(value) && !isNaN(Number(value)));
}

const toUnit = (value: number | string, unit = 'px') => {
  if (isNumberish(value)) {
    return `${value}${unit}`;
  } else if (isString(value)) {
    return value;
  }
}
<colgroup>
  <col  
    v-for="column in columns"
    :key="column.key"
    :style="column.width
      ? { width: toUnit(column.width) }
      : undefined
    "
  >
</colgroup>

例如範例中的 Address 欄位我們設定寬度為 40%,效果就會像是這樣:

簡單的 AtomicTable 加上 &lt;col&gt; 後的效果

<col> 很好用,但能透過 CSS 設定的屬性有限,只有像是:backgroundbordervisibilitywidth 等。

<col> 元素上接受 widthalign 等屬性,但這些屬性在 HTML5 中已經被棄用。

<col width="40%" align="left">

接著我們將 alignclass 綁定到 <th><td> 上。

<th
  v-for="column in columns"
  :key="column.key"
  class="atomic-table__cell"
  :class="[
    `atomic-table__cell--${column.align || 'left'}`,
    column.class,
    column.headCellClass,
  ]"
>
  {{ column.label }}
</th>
<td
  v-for="column in columns"
  :key="column.key"
  class="atomic-table__cell"
  :class="[
    `atomic-table__cell--${column.align || 'left'}`,
    column.class,
    column.bodyCellClass,
  ]"
>
  {{ item[column.key] }}
</td>

我們可以讓 bodyCellClass 的功能再更彈性,例如使用者可以依照資料的不同來顯示不同的樣式。所以這裡我們允許 bodyCellClass 傳入一個 function,這個 function 可以接受到當下欄位的資料、第幾個 Row 與整個 Row 的資料。

interface TableColumn {
  bodyCellClass?:
    | string
    | any[]
    | Record<string, any>
    | ((key: string, index: number, value: any, item: TableItem) => any);
}

我們判斷使用者傳入的 bodyCellClass 是否為 function,如果是的話我們把需要的資料帶進去。

<td
  v-for="column in columns"
  :key="column.key"
  class="atomic-table__cell"
  :class="[
    `atomic-table__cell--${column.align || 'left'}`,
    column.class,
    isFunction(column.bodyCellClass)
      ? column.bodyCellClass(item[column.key], index, item) 
      : column.bodyCellClass,
  ]"
>
  {{ item[column.key] }}
</td>

這樣開發人員在使用時就可以依照資料的不同來顯示不同的樣式。

const columns: TableColumn[] = [
  {
    key: 'age',
    label: 'Age',
    align: 'left',
    bodyCellClass: (value) => value > 30 ? 'text-blue-500' : '',
    sortable: true,
    render:  (value) => `${value} years old`,
  },
]

同樣的作法我們可以套用到 render 上。

<td
  v-for="column in columns"
  :key="column.key"
  class="atomic-table__cell"
>
  {{ isFunction(column.render) 
    ? column.render(item[column.key], index, item) 
    : item[column.key]
  }}
</td>

這樣我們也可以針對每個欄位的資料做格式化。

const columns: TableColumn[] = [
  {
    key: 'age',
    label: 'Age',
    align: 'left',
    bodyCellClass: (value) => value > 30 ? 'text-blue-500' : '',
    sortable: true,
    render:  (value) => `${value} years old`,
  },
]

如果可以,我們可以在每個欄位加上 Slot,如果遇到需要更複雜的需求時,我們可以讓使用者自行定義欄位的內容。

<td
  v-for="column in columns"
  :key="column.key"
  class="atomic-table__cell"
>
  <slot
    :index="index"
    :item="item"
    :name="`column:${column.key}`"
    :value="item[column.key]"
  >
    {{ isFunction(column.render) 
      ? column.render(item[column.key], index, item) 
      : item[column.key]
    }}
  </slot>
</td>

這裡用了動態的 Slot Name 處理,這樣開發人員就可以針對特定欄位做替換。

<AtomicTable :columns="columns" :items="items">
  <template #column:name="{ value }">
    <strong>
      {{ value.toUpperCase() }}
    </strong>
  </template>
</AtomicTable>

接著我們實作排序的功能,僅當欄位的 sortablesort 皆有啟用排序按鈕才會顯示。

<th
  v-for="column in columns"
  :key="column.key"
  class="atomic-table__cell"
>
  <span class="atomic-table__content">
    <span>
      {{ column.label }}
    </span>
    <button
      v-if="sort && column.sortable"
      type="button"
      @click="onSort(column.key)"
    >
      <SortIcon />
    </button>
  </span>
</th>

排序功能與邏輯在許多 UI Library 都會被內建在元件裡面。這樣雖然很方便,但限制了一些彈性,例如有時候我們改變了排序會跟後端重新取得一包資料,而不是依照現有資料重新排序。我們這裡實作只改變狀態,而不主動幫使用者排序。

我們只處理排序資料的改變,排序資料變化規則如下:

  • 如果點選的欄位與目前排序的欄位不同,則切換到新的欄位並重新定義方向為遞增(asc)。
  • 如果點選的欄位與目前排序的欄位相同,則切換排序方向,排序方向切換如下:遞增(asc)-> 遞減(desc)-> 不排序 -> 遞增。
const NEXT_DIRECTION = {
  asc: 'desc',
  desc: undefined,
  undefined: 'asc',
} as const;

const onSort = (key: string) => {
  if (!props.sort) return;

  const { column, direction: dir } = props.sort;
  const direction = column === key ? NEXT_DIRECTION[`${dir}`] : 'asc';

  emit('update:sort', {
    column: key,
    direction,
  });
};

接著,開發人員就可以依照需求來處理排序的邏輯。

AtomicTable 排序功能

最後再加上一些樣式看起來就完成了簡單的 <AtomicTable>

進階功能

表格多選

如果專案中經常需要讓使用者選取多個 Row 後進行操作,我們可以加入多選的功能,讓開發人員不需要自己定義欄位並且撰寫選取的邏輯。

我們在 props 上加上 selected 來定義選取的資料,並且在 emits 上加上 update:selected 來更新選取的資料。

interface AtomicTableProps {
  // ...
  selected?: TableItem[] | Set<TableItem>;
}

interface AtomicTableEmits {
  // ...
  (event: 'update:selected', value: TableItem[] | Set<TableItem>): void;
}

我們會使用 <input type="checkbox"> 來實作,在 Vue 3 中不僅支援陣列也支援 Set,所以我們可以使用 Set 來儲存選取的資料。

我們可以依照 selected 是否不為 undefined 來判定使用者是否需要多選功能。

const isSelectable = computed(() => {
  const { selected } = props;
  return Array.isArray(selected) || isSet(selected);
});

接著,我們需要準備三筆資料:

  • isChecked:判斷是否所有 Row 都被選取。

    const isChecked = computed({
      get() {
        return isIndeterminate.value || size(props.selected) === props.items.length;
      },
      set(value) {
        if (!selectedWritable.value) return;
    
        if (Array.isArray(selectedWritable.value)) {
          selectedWritable.value = value ? props.items.slice() : [];
        } else {
          selectedWritable.value = new Set(value ? props.items : []);
        }
      },
    });
    
    const size = (value: any[] | Set<any> | undefined) => {
      return value
        ? Array.isArray(value)
          ? value.length
          : isSet(value)
          ? value.size
          : 0
        : 0;
    };
    
  • isIndeterminate:判斷是否有部分 Row 被選取。

    const isIndeterminate = computed(() => {
      const { selected, items } = props;
      const count = size(selected);
      return count > 0 && count < items.length;
    });
    
  • selectedWritable:選取的資料。

    const selectedWritable = computed({
      get() {
        return props.selected;
      },
      set(value) {
        value && emit('update:selected', value);
      },
    });
    

接著我們就把一一加入到表格中。

<colgroup>
  <col
    v-if="isSelectable"
    :style="{ width: '42px' }"
  >
  <!-- 其他欄位 -->
</colgroup>
<thead class="atomic-table__row">
  <th
    v-if="isSelectable"
    class="atomic-table__cell atomic-table__checkbox atomic-table__cell--center"
  >
    <input
      v-model="isChecked"
      type="checkbox"
      :indeterminate="isIndeterminate"
    >
  </th>
  <!-- 其他欄位 -->
</thead>
<tr
  v-for="(item, index) in items"
  :key="item[itemKey]"
  class="atomic-table__row"
>
  <td
    v-if="isSelectable"
    class="atomic-table__cell atomic-table__checkbox atomic-table__cell--center"
  >
    <input
      v-model="selectedWritable"
      :value="item"
    >
  </td>
  <!-- 其他欄位 -->
</tr>

這樣一來我們就完成了表格多選的功能了!

AtomicTable 多選功能

無障礙

為了讓使用輔助技術的使用者能夠更好地了解表格的內容,我們需要加上一些元素與無障礙相關的設定,讓我們的元件更加友善。

表格標題

表格標題可以幫助使用者更快速地了解表格的內容,我們可以使用 <caption> 來定義表格的標題。我們可以在 <AtomicTable> 上接受一個可選的 caption 來定義表格的標題。

interface AtomicTableProps {
  // ...

  /**
   * 表格標題
   */
  caption?: string;

  /**
   * 表格標題位置
   */
  captionSide?: 'top' | 'bottom' | 'hidden';
}

如果 <caption> 存在,它必須被放在 <table> 的第一個子元素(最上方)。

<table class="atomic-table__table">
  <caption 
    v-if="caption" 
    class="atomic-table__caption"
  >
    {{ caption }}
  </caption>
  <!-- ... -->
</table>

AtomicTable 表格標題

我們不一定總是希望 <caption> 出現在表格最上面,或是被顯示出來。這時可以透過 CSS 的 caption-side 來設定 <caption> 的位置。

.atomic-table {
  &__caption {
    padding: 8px;

    &--top {
      caption-side: top;
    }

    &--bottom {
      caption-side: bottom;
    }

    &--hidden {
      @include sr-only;
    }
  }
}

這樣一來,不論是一般使用者或是使用輔助技術瀏覽網頁的使用者都能更清楚地了解表格的內容。

ARIA 屬性

aria-label

<AtomicTable> 中,排序用的按鈕我們使用了 SVG Icon,這對於使用輔助技術的使用者來說可能會造成一些困擾,因為他們無法得知目前顯示的 Icon 是什麼意思。這時我們可以透過 aria-label 來定義按鈕的功能。

<button
  v-if="sort && column.sortable"
  type="button"
  @click="onSort(column.key)"
  :aria-label="`排序 ${column.label}`"
>
  <SortIcon />
</button>

aria-sort

當使用者點選排序按鈕時,我們可以透過 aria-sort 來告訴使用者目前的排序狀態。

<th
  v-for="column in columns"
  :key="column.key"
  class="atomic-table__cell"
  :aria-sort="getAriaSort(column)"
>
  {{ column.label }}
</th>
const getAriaSort = (column: TableColumn): AriaAttributes['aria-sort'] => {
  if (!column.sortable || !props.sort) return undefined;

  const { column: name, direction } = props.sort;

  if (name !== column.key) return 'none';
  return direction === 'asc'
    ? 'ascending'
    : direction === 'desc'
    ? 'descending'
    : 'none';
};

這樣一來,當欄位有排序功能時,使用者就可以更清楚地了解目前的排序狀態。

<tr class="atomic-table__row">
  <th aria-sort="ascending">...</th>
  <th aria-sort="none">...</th>
  <th>...</th>
</tr>

總結

<table> 在網頁中是一個很「聰明」的元素,它可以依照內容自動調整每個欄位最適合的寬度。不過,由於規劃表格時所需要的元素比較多,必要的就有 <thead><tbody><tr><th><td> 等,模板結構很容易變得複雜且難以維護。

加入 <AtomicTable> 之後就會變得容易很多,我們只要定義好每個欄位與欄位資料,就可以快速地將想要的表格呈現出來。樣式的部分可以透過像是 bodyRowClassbodyCellClasscolumn.bodyCellClass 等等方式來客製化每個 Row 甚至每個 Cell 的 Class。如果想要自定義每個欄位的內容,可以使用 column.render 或是動態 slot 的方式針對每個欄位做更進階的處理。

另外,<AtomicTable> 也提供了排序功能,讓使用者可以依照自己的需求來排序資料。不過,由於不希望將排序的邏輯耦合在元件中,我們只處理排序資料的變更,而不主動幫使用者排序。

最後,我們幫 <AtomicTable> 加入了多行選取的功能與無障礙相關的設定,讓使用者可以更方便地使用表格,並且讓表格對於使用輔助技術的網頁使用者更友善。

參考資料


上一篇
[為你自己寫 Vue Component] AtomicProgress
下一篇
[為你自己寫 Vue Component] AtomicTooltip
系列文
為你自己寫 Vue Component30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言